test

Build a Mock Backend with a Service Worker

20 June, 2024 | 6 min read |

Let's set up a service worker for our app that intercepts API requests and returns its data when doing local development.

But Why?

Recently I was working on a project that has a complicated backend architecture that takes quite a bit to spin up. I was specifically working on the front-end web app for this project and I wanted to interact with the UI as if the backend API were running without actually running it. I tried a simple approach of hard-coding some test data but that was only good for display purposes, I couldn't perform any CRUD actions in the app.

As you can imagine this got very annoying when testing the app without running the backend APIs as functionality was limited. I had the idea for creating a service worker, that intercepts fetch requests, maintains its state, and returns data. Thus making it appear as if the API is running to the front end and enabling the various CRUD flows in the app.

Sure this adds a level of complexity and yet another thing to maintain but I believe the tradeoff is well worth it, especially with a good TypeScript type system in place.

ESbuild Config

Firstly we are going to install esbuild, which we will use to compile our TypeScript code and you'll do that with the following command:

npm install --save-dev esbuild
copied

Next, let's create a file that executes esbuild and runs it in watch mode so that it recompiles our service worker as we make changes to it.

import esbuild from 'esbuild';

const buildSettings = {
  entryPoints: ['PATH_TO_SERVICE_WORKER_TS_FILE'],
  outfile: 'OUTPUT_PATH_FOR_COMPILED_SERVICE_WORKER_FILE',
  bundle: true
};

const startWatching = async () => {
  const ctx = await esbuild.context(buildSettings);

  await ctx.watch();
}

startWatching();
copied

Now when we start developing our service worker just run the esbuild file we just created with Node

Service Worker Setup

Setting up the service worker is as simple as creating a JavaScript file. It gets a little complex when you want to write it in TypeScript but not by much and luckily I'm here to help 😊.

In our service worker file, we are going to add 2 event listeners, one for the install event and one for the fetch event.

self.addEventListener("install", (event) => {
  // TODO add some stuff here to init state later
});

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  // TODO add mocked API handlers here
});
copied

These 2 event listeners will be where we will do all of the work for initializing the database with some data and where we will intercept fetch requests and return our data.

If you want to read up more about these 2 events** **you can do so at the following documentation pages:

Local Storage Setup

To make it easier to persist data in our service worker we will be using a library that makes it easy to interact with the browser's storage, localForage. To install it run the following:

npm install localforage -D
copied

Now at the top of the service worker file, we will import this library like so:

import localforage from 'localforage';
copied

I do recommend adding a call to config localForage at the top of the service worker file like so:

localforage.config({
    name: 'APP_NAME' //This can be anything TBH
});
copied

Lastly, in the install event listener we set up in the previous section I recommend adding some logic to initialize some data if none exists.

Handling Fetch Requests

Now the nitty gritty, earlier we set up a fetch event listener that will intercept every fetch request sent by our application. The event this listener receives will include all request data so we will be referencing that object quite a bit. For every API route we want to route we will have to return a JavaScript Response object but if we want to simply ignore/skip an API route and let it hit the network we simply do nothing in our code.

The first thing you may have noticed is that we are getting the URL in the listener via the following line of code:

const url = new URL(event.request.url);
copied

We will have to check the URL either via a switch block or if statement to conditionally run methods for the various API routes such as the following:

if (url.pathName === '/api/giveMeCookies') {
  //Mock the API route/logic here
}
copied

Now if we want to return data from this event listener for this route we can modify the code to look like the following:

if (url.pathName === '/api/giveMeCookies') {
  return event.respondWith(new Response(JSON.stringify([
    'chocolate chip',
    'oatmeal rasin',
    'sugar',
    'double chocolate chip'
    'carrot cake'
  ]), {
    status: 200
  }));
}
copied

This should return the data to your app and you can verify this in the network tab of your browser dev tools.

Now that's good but what if we want to access persisted data? That's where it gets a little complicated. Since localForage deals with promises we have to do our work in a separate method and return that method like so:


const getCookies = async () => {
  const retrievedCookies = await localForage.getItem<Array<string>>('cookies');

  if (retrievedCookies) {
    return new Response(JSON.stringify(retrievedCookies), {
      status: 200
    });
  }

  return new Response(JSON.stringify([]), {
    status: 500
  });
};

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.pathName === '/api/giveMeCookies') {
    return event.respondWith(getCookies());
  }
});
copied

Hopefully, you can see how to modify this code now to support CRUD API routes and how you can modify persisted data via localForage. I highly recommend using strong types for all of your service worker code and front-end fetch request code to make sure both ends are adhering to the data types/structure.

Register Your Service Worker

Last but not least we need to register this service worker which is super simple just put the following code in your front-end code and you should be good to go:

if ("serviceWorker" in navigator) {
  try {
    const registration = await navigator.serviceWorker.register("/PATH_TO_COMPILED_SERVICE_WORKER_FILE.js", {
      scope: "/",
    });
  } catch (error) {
    console.error(`Registration failed with ${error}`);
  }
}
copied

Keep in mind your compiled service worker code must be available to your client code so that it can register it. Without so the service worker registration will fail. Also, note that when making changes to your service worker code you may have to unregister it manually through your browser's dev tools for your changes to be picked up 🙃.

Thats it! Now you can mock your API routes without your front-end code knowing and hopefully improve your local development experience along the way!